[Swift] Alamofireの仕組みを使ってiOSからAWS S3のPre-Signed URLに画像をアップロードしてみる
はじめに
モバイルアプリサービス部の中安です。
Amazon S3
のPre-Signed URL
(署名付きURL) を利用すると、Amazon S3
に限定的にアクセスしてファイルをアップロードすることができます。
この仕組みを使用すると、AWSの認証なしで直接Amazon S3
にオブジェクトをアップロードすることが可能になります。(最近、教えてもらいました)
で、iOSアプリ開発でネットワーキングのライブラリといえば Alamofire
を使うことが多いと思いますが、 この仕組みを使って、Pre-Signed URL
に対して画像をアップロードすることに奮闘したので、その経緯と解決法をアウトプットしてみたいと思います。
ネットによくあるサンプル
Alamofire
を利用して画像をアップロードするサンプルとしては、以下のようなソースコードがネット上ではよく引っかかります。
typealias ResponsedHandler = (Error?) -> Void func upload(image: UIImage, responsed: @escaping ResponsedHandler) { let data = image.jpegData(compressionQuality: 1)! Alamofire.upload( multipartFormData: { formData in formData.append(data, withName: "test", fileName: "test.jpeg", mimeType: "image/jpeg") }, to: "https://xxxxx.co.jp/image-upload/", encodingCompletion: { encodingResult in switch encodingResult { case .success(let uploadRequest, _, _): uploadRequest.response { response in if let error = response.error { // エラー処理 responsed(error) return } // 成功処理 responsed(nil) } case .failure(let error): // エラー処理 responsed(error) } } ) }
しかし、この 引数である to:
のURLを Amazon S3
によって発行された Pre-Signed URL
にしてみると、 403 Forbidden
エラーになってしまいます。
何故ダメなのか
POSTではなく、PUTにする必要がある
Alamofire.upload
メソッドは HTTPメソッド
がデフォルトで POST
になっていますが、これを PUT
にしてあげる必要があるようです。
Content-Type 問題
Pre-Signed URL
は、指定の仕方によりますが、アップロードする側の Content-Type
を制限しています。
https://hogehoge.amazonaws.com/...&content-type=image/jpeg...
このようなURLの場合は、JPEG画像のみしか受け付けてくれません。
勝手気ままに色々なファイルがアップロードされるのも困るでしょうから、このように制限がかかるのでしょう。(上記の例はわかりやすく image/jpeg
と書いていますが、本来はURLエンコードされています)
そのためアップロードする側は、リクエストヘッダの Content-Type
に image/jpeg
を指定してやり、
リクエストボディに含むデータもJPEGデータにしなくてはなりません。
改修してみる
上記のような問題があるため、回避策としてソースコードを以下のように変えてみました。
(結果として変えてみたところからハマってしまいました…汗)
typealias ResponsedHandler = (Error?) -> Void func upload(image: UIImage, responsed: @escaping ResponsedHandler) { let data = image.jpegData(compressionQuality: 1)! Alamofire.upload( multipartFormData: { formData in formData.append(data, withName: "test", fileName: "test.jpeg", mimeType: "image/jpeg") formData.contentType = "image/jpeg" }, to: "https://xxxxx.co.jp/image-upload/", method: .put, encodingCompletion: { encodingResult in switch encodingResult { case .success(let uploadRequest, _, _): uploadRequest.response { response in if let error = response.error { // エラー処理 responsed(error) return } // 成功処理 responsed(nil) } case .failure(let error): // エラー処理 responsed(error) } } ) }
前述の問題への対応として、ハイライトしている2箇所を変更してみました。
すると、結果としては 200/OK
が返ってきました。
しかし、S3の中身を見てみると画像ファイルとして壊れた状態になってしまいました。
改修は何故うまくいかなかったのか
なぜ画像ファイルが壊れた状態になってしまったのか。 ここはちょっとハマりました。 しかし、ソースコードをよく見れば答えは自ずと出るのでした。
formData.append() で append しているものは?
upload
メソッドの encodingCompletion
という引数は「エンコーディングが完了した」ときのコールバックです。
ここでいう「エンコーディング」というのは何だろうということですが、
そもそも Alamofire.upload
というメソッドは、 MultipartFormData
というもの (上記ソースでいうところの formData
) が登場しているように、 HTML
のフォーム処理でよく使用する multipart/form-data
+ boundary
を前提として作られているようです。
引数 multipartFormData
のコールバックの中で formData.append()
していくのですが、
Alamofire
内部のソースの中身を追っていくと、
データを追加していく分だけ boundary
で区切っていっていることが分かりました。
区切ったものに対してエンコーディングの処理を加えていることから、 encodingCompletion
でアップロードしようとしているデータには、
純粋な画像データだけではなく、それ以外の boundary
用の文字列なども含んでのエンコーディング結果ということになります。
余計なものを含んでいるゆえに、アップロードされた画像はファイルとしては不正なものになっていました。
再改修してみる
方針を変えてみよう
前述のように Alamofire.upload
メソッドを利用することは、multipart/form-data
+ boundary
前提であるため、
たいていのアップロード処理には便利かもしれませんが、Amazon S3 Pre-Signed URL
には向かないことが分かりました。
Alamofire.upload
メソッドの中身を追っていくと、Alamofire.SessionManager
というクラスのアップロード処理に行き当たります。
開発者が簡単に使えるようにその中身をマルチパートフォームデータ用にラップして提供されているということですね。
ということで、Alamofire.SessionManager
が行っていることから、マルチパートフォームデータ用の処理を取り除く形で実装してみることにしました。
スレッドの処理を先に作っておく
Alamofire.SessionManager.upload
でも実装されていますが、ネットワーキングは非同期処理を行うため、メインスレッド外で処理を行います。
先にそのあたりを作っておきます。
typealias ResponsedHandler = (Error?) -> Void func upload(image: UIImage, responsed: @escaping ResponsedHandler) { DispatchQueue.global(qos: .utility).async { self.uploadOnSubThread(image: image, responsed: responsed) } } // ここはサブスレッド内で行われます func uploadOnSubThread(image: UIImage, responsed: @escaping ResponsedHandler) { let data = image.jpegData(compressionQuality: 1)! // 後述 } // メインスレッドに戻して処理を終えたいときは、これを呼び出す func finishOnMainThread(error: Error?, responsed: @escaping ResponsedHandler) { DispatchQueue.main.async { responsed(error) } }
Alamofireのアップロードリクエストを作る
やりたいこと
最初のサンプルソースを振り返って抜粋してみると
Alamofire.upload( (・・・中略・・・) encodingCompletion: { encodingResult in switch encodingResult { case .success(let uploadRequest, _, _): uploadRequest.response { response in (・・・以下略・・・)
encodingCompletion:
の中の encodingResult
が成功の時には uploadRequest
が渡されてきます。
他の Alamofire
の処理も同様であるように、このオブジェクトに対して response()
や responseJSON()
などを呼び出すことでネットワーキング処理が走り出します。つまり、このオブジェクトが生成できればこっちのものというわけです。
やってみる
func uploadOnSubThread(image: UIImage, responsed: @escaping ResponsedHandler) { let data = image.jpegData(compressionQuality: 1)! do { let url = URL(string: "https://(Pre-Signed URL)")! let urlRequest = try URLRequest( url: url, method: .put, headers: ["Content-Type": "image/jpeg"] ) let uploadRequest = Alamofire.upload(data, with: urlRequest) // 後述 } catch { finishOnMainThread(error: error, responsed: responsed) } }
Alamofire
では URLRequest
を拡張して、HTTPメソッド
と HTTPヘッダ
を引数にとって生成してくれるイニシャライザが用意してくれています。
ありがたいのでこちらを使わせていただこうと思います。
ただし、例外を投げるイニシャライザなので、do-catch
で囲ってやる必要があります。
で、Alamofire.upload
は数々のオーバロードがあるのですが、実は
func upload(_ data: Data, with urlRequest: URLRequestConvertible) -> UploadRequest
こんなにもシンプルなものが存在しておりました。。(これが見つけられずにハマるなど・・・)
♪探してたもんはこんなシンプルなもんだったんだ
by Mr.Children
このメソッドを使ってアップロードリクエストを取得してやります。
あとは、ネットワーキング処理
というわけで、uploadRequest
を使ってアップロードを実行してやります。
下のソースのハイライトした部分になります。
func uploadOnSubThread(image: UIImage, responsed: @escaping ResponsedHandler) { let data = image.jpegData(compressionQuality: 1)! do { let url = URL(string: "https://(Pre-Signed URL)")! let urlRequest = try URLRequest( url: url, method: .put, headers: ["Content-Type": "image/jpeg"] ) let uploadRequest = Alamofire.upload(data, with: urlRequest) uploadRequest.response { [weak self] response in guard let self = self else { return } if let error = response.error { self.finishOnMainThread(error: error, responsed: responsed) return } self.finishOnMainThread(error: nil, responsed: responsed) } } catch { finishOnMainThread(error: error, responsed: responsed) } }
これで S3 にはJPEG画像が壊れずにアップロードされました。
まとめ
「Alamofire 画像アップロード」で検索すると、最初に書いたようにマルチパートフォームデータ前提の方法が引っかかります。 自分もそれらを参考にしてみたのですが、ちょっとその方法に引っ張られてしまいました。
後から冷静に考えれば「そりゃうまくいかないよね」って思うところなのですが、ハマる時ってそういうものですね。。。
Amazon S3 Pre-Signed URL
に iOSアプリ から画像アップロードするサンプルがなかなか引っかからなかったので、
この記事がどなたかの参考になれば幸いです。
ちなみに Androidの場合は こちらの記事 (少し古め) も参考ください
One more thing...
おまけです。
Alamofire
のリクエストオブジェクトの debugDescription
を呼び出すと、curl
コマンドが取得できます
let uploadRequest = Alamofire.upload(data, with: urlRequest) print(uploadRequest.debugDescription)
こんな感じ
$ curl -v \ -X PUT \ -H "User-Agent: hogehoge Alamofire/x.x.x" \ -H "Content-Type: image/jpeg" \ -H "Accept-Language: ja-JP;q=1.0" \ -H "Accept-Encoding: gzip;q=1.0, compress;q=0.5" \ "https://(Pre-Signed URL)"
で、ここに -T
オプションでローカルマシン内のJPEG画像のファイルパスを与えてやり、
ターミナルで叩いてみるとS3にアクセスされます。
Pre-Signed URL
側の問題だったのか、アプリ側の問題だったのかを切り分ける確認のためには
サクッとできる方法だったので、ご紹介しておきます。
以上です。